Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 02eda4330456129871f376f6b0d0ece71fb7c6a3


Parents : e06f20f
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T02:27:33-05:00

feat(call): update audio input/output device handling and implement fallback for getUserMedia with improved error management

Changes

1 files changed, 190 insertions(+), 60 deletions(-)


Diff

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 395172a5..a1dab6a2 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -586,8 +586,8 @@
"
>
<option
- v-for="d in audioInputDevices"
- :key="d.deviceId"
+ v-for="(d, idx) in audioInputDevices"
+ :key="d.deviceId || `in-${idx}`"
:value="d.deviceId"
>
{{ d.label || "Microphone" }}
@@ -609,8 +609,8 @@
"
>
<option
- v-for="d in audioOutputDevices"
- :key="d.deviceId"
+ v-for="(d, idx) in audioOutputDevices"
+ :key="d.deviceId || `out-${idx}`"
:value="d.deviceId"
>
{{ d.label || "Speaker" }}
@@ -2128,6 +2128,7 @@ import Toggle from "../forms/Toggle.vue";
import ToastUtils from "../../js/ToastUtils";
import RingtoneEditor from "./RingtoneEditor.vue";
import AudioWaveformPlayer from "../messages/AudioWaveformPlayer.vue";
+import telephonePcmCaptureWorkletUrl from "../../js/telephone-pcm-capture.worklet.js?url";
export default {
name: "CallPage",
@@ -2397,6 +2398,40 @@ export default {
getAudioContextConstructor() {
return window.AudioContext || window.webkitAudioContext || null;
},
+ pickWebAudioMicConstraints(mediaDevices) {
+ const canEnumerate = this.hasEnumerateDevicesApi(mediaDevices);
+ const validIds = canEnumerate
+ ? new Set(
+ (this.audioInputDevices || [])
+ .filter((d) => d.kind === "audioinput" && d.deviceId)
+ .map((d) => d.deviceId)
+ )
+ : new Set();
+ const sid = this.selectedAudioInputId;
+ if (sid === "__meshchat_default_in__") {
+ return { audio: true };
+ }
+ const id = sid && validIds.has(sid) ? sid : null;
+ return id ? { audio: { deviceId: { exact: id } } } : { audio: true };
+ },
+ async getUserMediaWithMicFallback(mediaDevices) {
+ const constraints = this.pickWebAudioMicConstraints(mediaDevices);
+ try {
+ return await mediaDevices.getUserMedia(constraints);
+ } catch (e) {
+ if (
+ (e?.name === "NotFoundError" || e?.name === "OverconstrainedError") &&
+ constraints?.audio &&
+ typeof constraints.audio === "object" &&
+ constraints.audio.deviceId
+ ) {
+ this.selectedAudioInputId = null;
+ this.logWebAudioFailure("getUserMedia-fallback-wide", e);
+ return await mediaDevices.getUserMedia({ audio: true });
+ }
+ throw e;
+ }
+ },
logWebAudioFailure(stage, error) {
const appImage = Boolean(
window.electron && typeof navigator?.userAgent === "string" && navigator.userAgent.includes("AppImage")
@@ -2441,7 +2476,7 @@ export default {
const previousValue = this.config.telephone_web_audio_enabled;
this.config.telephone_web_audio_enabled = newVal;
try {
- if (newVal) {
+ if (newVal && this.activeCall) {
const permitted = await this.requestAudioPermission();
if (!permitted) {
this.config.telephone_web_audio_enabled = false;
@@ -2451,7 +2486,9 @@ export default {
}
await this.updateConfig({ telephone_web_audio_enabled: newVal });
if (newVal) {
- await this.startWebAudio();
+ if (this.activeCall) {
+ await this.startWebAudio();
+ }
} else {
this.stopWebAudio();
}
@@ -2461,9 +2498,31 @@ export default {
}
},
async startWebAudio() {
- if (this.audioWs) {
+ if (!this.activeCall) {
+ return;
+ }
+ if (this.audioWs && this.audioWs.readyState === WebSocket.OPEN) {
+ try {
+ this.audioWs.send(JSON.stringify({ type: "attach" }));
+ } catch {
+ // ignore
+ }
+ if (
+ this.audioCtx &&
+ this.audioCtx.state === "suspended" &&
+ typeof this.audioCtx.resume === "function"
+ ) {
+ try {
+ await this.audioCtx.resume();
+ } catch {
+ // ignore
+ }
+ }
return;
}
+ if (this.audioWs) {
+ this.stopWebAudio();
+ }
try {
const mediaDevices = this.getMediaDevicesApi();
if (!mediaDevices) {
@@ -2475,23 +2534,28 @@ export default {
return;
}
await this.refreshAudioDevices();
- const canEnumerate = this.hasEnumerateDevicesApi(mediaDevices);
- const validDeviceIds = canEnumerate
- ? new Set((this.audioInputDevices || []).map((d) => d.deviceId))
- : new Set();
- const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
- const constraints = hasSelectedDevice
- ? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
- : { audio: true };
- const stream = await mediaDevices.getUserMedia(constraints);
+ const stream = await this.getUserMediaWithMicFallback(mediaDevices);
this.audioStream = stream;
+ await this.refreshAudioDevices();
if (!this.audioCtx) {
const AudioContextCtor = this.getAudioContextConstructor();
if (!AudioContextCtor) {
throw new Error("AudioContext is unavailable");
}
- this.audioCtx = new AudioContextCtor({ sampleRate: 48000 });
+ try {
+ this.audioCtx = new AudioContextCtor({ sampleRate: 48000 });
+ } catch {
+ this.audioCtx = new AudioContextCtor();
+ }
+ }
+
+ if (this.audioCtx.state === "suspended" && typeof this.audioCtx.resume === "function") {
+ try {
+ await this.audioCtx.resume();
+ } catch {
+ // ignore
+ }
}
const source = this.audioCtx.createMediaStreamSource(stream);
@@ -2499,21 +2563,36 @@ export default {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${wsProtocol}//${window.location.host}/ws/telephone/audio`;
- const workletLoaded = false; // disabled to avoid CSP violations from blob worklets
- if (!workletLoaded) {
- const processor = this.audioCtx.createScriptProcessor(1024, 1, 1);
- processor.onaudioprocess = (event) => {
- if (!this.audioWs || this.audioWs.readyState !== WebSocket.OPEN) return;
- const input = event.inputBuffer.getChannelData(0);
- const pcm = new Int16Array(input.length);
- for (let i = 0; i < input.length; i += 1) {
- pcm[i] = Math.max(-1, Math.min(1, input[i])) * 0x7fff;
- }
- this.audioWs.send(pcm.buffer);
- };
- source.connect(processor);
- this.audioProcessor = processor;
+ if (!this.audioCtx.audioWorklet || typeof this.audioCtx.audioWorklet.addModule !== "function") {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ new Error("AudioWorklet is unavailable in this browser"),
+ "start-preflight-audio-worklet"
+ );
+ return;
}
+ await this.audioCtx.audioWorklet.addModule(telephonePcmCaptureWorkletUrl);
+ const processor = new AudioWorkletNode(this.audioCtx, "telephone-pcm-capture", {
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ channelCount: 1,
+ });
+ processor.port.onmessage = (event) => {
+ if (!this.audioWs || this.audioWs.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ const data = event.data;
+ if (data && data.byteLength > 0) {
+ this.audioWs.send(data);
+ }
+ };
+ source.connect(processor);
+ this.audioWorkletNode = processor;
+ const silentGain = this.audioCtx.createGain();
+ silentGain.gain.value = 0;
+ this.audioSilentGain = silentGain;
+ processor.connect(silentGain);
+ silentGain.connect(this.audioCtx.destination);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
@@ -2522,6 +2601,29 @@ export default {
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "error") {
+ this.logWebAudioFailure("ws-server-error", new Error(msg.message || "unknown"));
+ if (
+ this.activeCall &&
+ typeof msg.message === "string" &&
+ msg.message.includes("No active call")
+ ) {
+ window.setTimeout(() => {
+ try {
+ if (this.audioWs && this.audioWs.readyState === WebSocket.OPEN) {
+ this.audioWs.send(JSON.stringify({ type: "attach" }));
+ }
+ } catch {
+ // ignore
+ }
+ }, 150);
+ }
+ }
+ } catch {
+ // ignore non-json
+ }
return;
}
this.playRemotePcm(event.data);
@@ -2551,18 +2653,20 @@ export default {
throw new Error("navigator.mediaDevices is unavailable");
}
if (this.hasEnumerateDevicesApi(mediaDevices)) {
- const devices = await mediaDevices.enumerateDevices();
- const hasAudioInput = devices.some((d) => d.kind === "audioinput");
- if (devices.length > 0 && !hasAudioInput) {
- ToastUtils.error(this.$t("call.no_audio_input_found"));
- return false;
+ try {
+ const devices = await mediaDevices.enumerateDevices();
+ const hasAudioInput = devices.some((d) => d.kind === "audioinput");
+ if (devices.length > 0 && !hasAudioInput) {
+ ToastUtils.error(this.$t("call.no_audio_input_found"));
+ return false;
+ }
+ } catch (enumErr) {
+ this.logWebAudioFailure("enumerate-devices-preflight", enumErr);
}
}
- const constraints = this.selectedAudioInputId
- ? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
- : { audio: true };
- const stream = await mediaDevices.getUserMedia(constraints);
+ await this.refreshAudioDevices();
+ const stream = await this.getUserMediaWithMicFallback(mediaDevices);
stream.getTracks().forEach((t) => t.stop());
await this.refreshAudioDevices();
return true;
@@ -2579,21 +2683,41 @@ export default {
}
},
async refreshAudioDevices() {
+ const defaultIn = {
+ deviceId: "__meshchat_default_in__",
+ kind: "audioinput",
+ label: "Default",
+ groupId: "",
+ };
+ const defaultOut = {
+ deviceId: "__meshchat_default_out__",
+ kind: "audiooutput",
+ label: "Default",
+ groupId: "",
+ };
try {
const mediaDevices = this.getMediaDevicesApi();
if (!mediaDevices) {
- this.audioInputDevices = [];
- this.audioOutputDevices = [];
+ this.audioInputDevices = [defaultIn];
+ this.audioOutputDevices = [defaultOut];
return;
}
if (!this.hasEnumerateDevicesApi(mediaDevices)) {
- this.audioInputDevices = [];
- this.audioOutputDevices = [];
+ this.audioInputDevices = [defaultIn];
+ this.audioOutputDevices = [defaultOut];
return;
}
const devices = await mediaDevices.enumerateDevices();
- this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
- this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");
+ let inputs = devices.filter((d) => d.kind === "audioinput");
+ let outputs = devices.filter((d) => d.kind === "audiooutput");
+ if (inputs.length === 0 || inputs.every((d) => !d.deviceId || String(d.deviceId).trim() === "")) {
+ inputs = [defaultIn];
+ }
+ if (outputs.length === 0 || outputs.every((d) => !d.deviceId || String(d.deviceId).trim() === "")) {
+ outputs = [defaultOut];
+ }
+ this.audioInputDevices = inputs;
+ this.audioOutputDevices = outputs;
if (!this.selectedAudioInputId && this.audioInputDevices.length) {
this.selectedAudioInputId = this.audioInputDevices[0].deviceId;
}
@@ -2602,8 +2726,8 @@ export default {
}
} catch (e) {
this.logWebAudioFailure("refresh-devices", e);
- this.audioInputDevices = [];
- this.audioOutputDevices = [];
+ this.audioInputDevices = [defaultIn];
+ this.audioOutputDevices = [defaultOut];
}
},
playRemotePcm(arrayBuffer) {
@@ -2620,7 +2744,10 @@ export default {
audioBuffer.copyToChannel(floatBuf, 0);
const bufferSource = this.audioCtx.createBufferSource();
bufferSource.buffer = audioBuffer;
- if (this.selectedAudioOutputId && "setSinkId" in HTMLMediaElement.prototype) {
+ const sinkId = this.selectedAudioOutputId;
+ const useSetSinkId =
+ sinkId && sinkId !== "__meshchat_default_out__" && "setSinkId" in HTMLMediaElement.prototype;
+ if (useSetSinkId) {
if (!this.remoteAudioEl) {
this.remoteAudioEl = new Audio();
this.remoteAudioEl.autoplay = true;
@@ -2629,9 +2756,7 @@ export default {
bufferSource.connect(dest);
bufferSource.start();
this.remoteAudioEl.srcObject = dest.stream;
- this.remoteAudioEl
- .setSinkId(this.selectedAudioOutputId)
- .catch((err) => console.warn("setSinkId failed", err));
+ this.remoteAudioEl.setSinkId(sinkId).catch((err) => console.warn("setSinkId failed", err));
} else {
bufferSource.connect(this.audioCtx.destination);
bufferSource.start();
@@ -2667,15 +2792,12 @@ export default {
}
this.audioProcessor = null;
}
- if (this.audioStream) {
- this.audioStream.getTracks().forEach((t) => t.stop());
- this.audioStream = null;
- }
- if (this.remoteAudioEl) {
- this.remoteAudioEl.srcObject = null;
- this.remoteAudioEl = null;
- }
if (this.audioWorkletNode) {
+ try {
+ this.audioWorkletNode.port.onmessage = null;
+ } catch {
+ // ignore
+ }
try {
this.audioWorkletNode.disconnect();
} catch {
@@ -2683,6 +2805,14 @@ export default {
}
this.audioWorkletNode = null;
}
+ if (this.audioStream) {
+ this.audioStream.getTracks().forEach((t) => t.stop());
+ this.audioStream = null;
+ }
+ if (this.remoteAudioEl) {
+ this.remoteAudioEl.srcObject = null;
+ this.remoteAudioEl = null;
+ }
if (this.audioSilentGain) {
try {
this.audioSilentGain.disconnect();


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────